Spring Data JPA - projection
contents
성능 최적화 기법인 Spring Data JPA Projection에 대해 상세하게 번역해 드리겠습니다.
기본적으로 findAll()을 사용하면, 하이버네이트는 테이블의 모든 컬럼을 조회하여 거대한 엔티티 객체에 매핑합니다.
- 문제점: 만약
User테이블에 컬럼이 50개인데, 목록 화면에서는 단순히name과email2개만 필요하다면 어떨까요? 불필요한 메모리와 네트워크 대역폭을 낭비하게 됩니다.
Spring Data JPA Projection을 사용하면 엔티티 관리라는 무거운 오버헤드 없이 원하는 특정 필드만 쏙 뽑아와서 커스텀 모델에 담을 수 있습니다.
이걸 유형별로 알아보겠습니다.
1. 세 가지 프로젝션 유형
Spring Data JPA에서 프로젝션을 구현하는 방법은 크게 인터페이스 기반, 클래스 기반(DTO), 동적 프로젝션 세 가지가 있습니다.
유형 A: 인터페이스 기반 프로젝션 (가장 흔함)
가장 쉬운 방법입니다. 가져오고 싶은 필드명과 일치하는 Getter 메서드를 가진 인터페이스를 정의하면 됩니다.
1. 인터페이스 정의:
public interface UserSummary {
// 스프링이 딱 이 두 필드만 조회합니다 (닫힌 프로젝션)
String getName();
String getEmail();
// 오픈 프로젝션 (고급):
// SpEL을 사용하여 런타임에 값을 계산해서 가져올 수 있습니다.
@Value("#{target.name + ' (' + target.email + ')'}")
String getFullName();
}
2. 리포지토리에서 사용:
public interface UserRepository extends JpaRepository<User, Long> {
// 반환 타입으로 엔티티 대신 인터페이스를 적습니다!
List<UserSummary> findByDepartment(String department);
}
- 작동 원리: 스프링이 실행 시점에 이 인터페이스의 가짜 구현체(프록시)를 자동으로 만들어줍니다. 생성되는 SQL은 필요한 컬럼만 딱 찍어서 가져옵니다 (
SELECT name, email FROM user ...).
유형 B: 클래스 기반 프로젝션 (DTO)
인터페이스 대신 구체적인 자바 클래스(DTO)를 사용합니다. 객체 내부에 로직을 넣거나 불변성(Immutability)을 보장하고 싶을 때 좋습니다.
1. DTO 정의 (Java 14+라면 Record가 최고입니다):
public record UserDto(String name, String email) {
// 주의: 파라미터 이름이 엔티티의 필드명과 정확히 일치해야 합니다!
}
일반 Class를 쓴다면 필드와 일치하는 생성자가 반드시 있어야 합니다.
2. 리포지토리에서 사용:
public interface UserRepository extends JpaRepository<User, Long> {
List<UserDto> findByDepartment(String department);
}
- 성능 참고: 하이버네이트가 쿼리 결과를 생성자에 바로 매핑합니다. 프록시를 만드는 과정이 없어서 인터페이스 방식보다 아주 미세하게 더 빠릅니다.
유형 C: 동적 프로젝션 (Dynamic Projections)
하나의 리포지토리 메서드로 상황에 따라 어떨 때는 Entity를, 어떨 때는 UserSummary를, 어떨 때는 UserDto를 반환받고 싶다면 어떻게 할까요?
제네릭(Generics) 사용:
public interface UserRepository extends JpaRepository<User, Long> {
// 제네릭 <T>를 사용하여 호출하는 시점에 타입을 정합니다
<T> List<T> findByDepartment(String department, Class<T> type);
}
사용 예시:
// 전체 엔티티 조회 (무거움)
List<User> entities = repo.findByDepartment("IT", User.class);
// 요약 정보만 조회 (가벼움)
List<UserSummary> summaries = repo.findByDepartment("IT", UserSummary.class);
2. 중첩 프로젝션 (Nested Projections - 주의할 점)
프로젝션은 연관 관계(Join)가 있는 데이터에도 사용할 수 있습니다.
엔티티 구조:
User $\rightarrow$ Department
프로젝션 인터페이스:
public interface UserWithDept {
String getName();
// 중첩 프로젝션!
DepartmentSummary getDepartment();
interface DepartmentSummary {
String getName();
String getLocation();
}
}
- 주의사항: 잘 동작하긴 하지만 조심해야 합니다. 때때로 하이버네이트가 최적화를 못 하고 부서(Department) 엔티티 전체를 다 조회한 뒤, 메모리 상에서 프로젝션을 수행하는 경우가 있습니다. 이렇게 되면 최적화의 의미가 퇴색되므로 반드시 생성된 쿼리 로그를 확인해야 합니다.
3. 왜 프로젝션을 써야 할까요? (이유)
- 성능 (SQL Select 절):
- 엔티티:
SELECT col1, col2, ... col50 FROM user - 프로젝션:
SELECT name, email FROM user - 효과: DB와 애플리케이션 사이의 데이터 전송량(네트워크 IO)을 획기적으로 줄입니다.
- 메모리 사용량 (Heap):
- 엔티티는 영속성 컨텍스트(1차 캐시)가 관리합니다. 변경 감지(Dirty Checking)를 위해 스냅샷을 저장하는 등 메모리를 많이 씁니다.
- 프로젝션은 읽기 전용(Read-Only) 입니다. 하이버네이트가 가져온 뒤 관여하지 않습니다. 대량의 목록 조회 시 힙 메모리를 엄청나게 아낄 수 있습니다.
- 보안 (데이터 노출 방지):
password나ssn(주민번호) 같은 민감한 필드를 실수로라도 프론트엔드에 보내는 사고를 방지할 수 있습니다. (애초에 DB에서 안 가져오니까요).
4. 비교표
| 특징 | 엔티티 조회 (Entity) | 인터페이스 프로젝션 | 클래스(DTO) 프로젝션 |
|---|---|---|---|
| 반환 타입 | List<User> |
List<UserSummary> |
List<UserDto> |
| SQL | SELECT * (모든 컬럼) |
SELECT 특정_컬럼 |
SELECT 특정_컬럼 |
| JPA 상태 | Managed (관리됨/추적됨) | Read-only (읽기 전용) | Read-only (읽기 전용) |
| 불변성 | 변경 가능 (Mutable) | 읽기 전용 | 읽기 전용 |
| 프록시 오버헤드 | 낮음 | 높음 (스프링이 프록시 생성) | 낮음 (생성자 직접 호출) |
| 최적 사용처 | 데이터 수정(CUD) | 조회 전용 API / 목록 | 고성능 조회 API |
개발자를 위한 요약
조회 중심(Read-Heavy) API (대시보드, 목록 보기, 검색 결과 등)를 만들 때는 프로젝션을 사용하세요.
- 설정이 가장 간편한 인터페이스 프로젝션으로 시작하세요.
- 극한의 성능과 프록시 오버헤드 제거가 필요하다면 Java Records (클래스 기반) 를 사용하세요.
- 엔티티는 데이터를 수정하고 다시 저장(
save())해야 하는 경우에만 사용하세요.
references